import 'dart:math' as math;
import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:tuple/tuple.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:quiver/core.dart';

class WeightEntry {
  String key;
  DateTime dateTime;
  double weight;
  String note;

  WeightEntry(this.dateTime, this.weight, this.note);

  WeightEntry.fromSnapshot(DataSnapshot snapshot)
      : key = snapshot.key,
        dateTime =
        new DateTime.fromMillisecondsSinceEpoch(snapshot.value["date"]),
        weight = snapshot.value["weight"].toDouble(),
        note = snapshot.value["note"];

  WeightEntry.copy(WeightEntry weightEntry)
      : key = weightEntry.key,
  //copy datetime
        dateTime = new DateTime.fromMillisecondsSinceEpoch(
            weightEntry.dateTime.millisecondsSinceEpoch),
        weight = weightEntry.weight,
        note = weightEntry.note;

  WeightEntry._internal(this.key, this.dateTime, this.weight, this.note);

  WeightEntry copyWith(
      {String key, DateTime dateTime, double weight, String note}) {
    return new WeightEntry._internal(
      key ?? this.key,
      dateTime ?? this.dateTime,
      weight ?? this.weight,
      note ?? this.note,
    );
  }

  toJson() {
    return {
      "weight": weight,
      "date": dateTime.millisecondsSinceEpoch,
      "note": note
    };
  }

  @override
  int get hashCode => hash4(key, dateTime, weight, note);

  @override
  bool operator ==(other) =>
      other is WeightEntry &&
          key == other.key &&
          dateTime.millisecondsSinceEpoch == other.dateTime
              .millisecondsSinceEpoch &&
          weight == other.weight &&
          note == other.note;
}

class _StatisticCardWrapper extends StatelessWidget {
  final double height;
  final Widget child;

  _StatisticCardWrapper({this.height = 120.0, this.child});

  @override
  Widget build(BuildContext context) {
    return new Row(
      children: [
        new Expanded(
          child: new Container(
            height: height,
            child: new Card(child: child),
          ),
        ),
      ],
    );
  }
}

void main() {
  runApp(MaterialApp(
    home: Scaffold(
      body: Center(
        child: GestureDetector(
          child: new _StatisticCardWrapper(
            child: new Padding(
                padding: new EdgeInsets.only(top:50.0),
                child: new ProgressChart([
                  WeightEntry(
                      new DateTime(2018, 9, 19), 75, null
                  ),
                  WeightEntry(
                      new DateTime(2018, 10, 19), 65, null
                  ),
                  WeightEntry(
                      new DateTime(2018, 11, 19), 68, null
                  ),
                  WeightEntry(
                      new DateTime(2018, 12, 19), 69, null
                  ),
                  WeightEntry(
                      new DateTime(2019, 1, 19), 70, null
                  ),
                  WeightEntry(
                      new DateTime(2019, 2, 19), 72, null
                  ),
                  WeightEntry(
                      new DateTime(2019, 3, 19), 73, null
                  ),
                  WeightEntry(
                      new DateTime(2019, 4, 9), 75, null
                  ),
//                  WeightEntry(
//                      new DateTime(2019, 5, 19), 71, null
//                  ),
//                  WeightEntry(
//                      new DateTime(2019, 6, 19), 70, null
//                  ),
//                  WeightEntry(
//                      new DateTime(2019, 7, 19), 66, null
//                  ),
//                  WeightEntry(
//                      new DateTime(2019, 8, 19), 68, null
//                  ),
                ])),
            height: 250.0,
          ),
        ),
      )
    ),
  ));
}

class ProgressChart extends StatefulWidget {
  final List<WeightEntry> entries;

  ProgressChart(this.entries);

  @override
  State<StatefulWidget> createState() {
    return new ProgressChartState();
  }
}

class ProgressChartState extends State<ProgressChart> {
  int numberOfDays = 30;
  int previousNumOfDays;

  @override
  Widget build(BuildContext context) {
    return new GestureDetector(
      onScaleStart: (scaleDetails) =>
          setState(() => previousNumOfDays = numberOfDays),
      onScaleUpdate: (ScaleUpdateDetails scaleDetails) {
        setState(() {
          int newNumberOfDays =
          (previousNumOfDays / scaleDetails.scale).round();
          if (newNumberOfDays >= 7) {
            numberOfDays = newNumberOfDays;
          }
        });
      },
      child: new CustomPaint(
        painter:
        new ChartPainter(_prepareEntryList(widget.entries), numberOfDays),
      ),
    );
  }

  List<WeightEntry> _prepareEntryList(List<WeightEntry> initialEntries) {
    DateTime beginningDate = _getStartDateOfChart(numberOfDays);
    List<WeightEntry> entries = initialEntries
        .where((entry) => entry.dateTime.isAfter(beginningDate))
        .toList();
    if (entries.isNotEmpty &&
        _isMissingEntryFromBeginningDate(beginningDate, entries) &&
        _isAnyEntryBeforeBeginningDate(beginningDate, initialEntries)) {
      _addFakeEntryOnTheChartBeginning(initialEntries, entries, beginningDate);
    }
    return entries;
  }

  /// Adds missing entry at the start of a chart.
  ///
  /// If user has not put entry on the date which is first date of a chart,
  /// it takes last known weight before that date and estimates linearly weight on the beginning date.
  /// Then it creates and adds fake [WeightEntry] with that weight and date.
  void _addFakeEntryOnTheChartBeginning(List<WeightEntry> initialEntries,
      List<WeightEntry> entries, DateTime beginningDate) {
    List<WeightEntry> entriesNotInChart =
    initialEntries.where((entry) => !entries.contains(entry)).toList();
    WeightEntry firstEntryAfterBeginning = entries.last;
    WeightEntry lastEntryBeforeBeginning = entriesNotInChart.first;
    WeightEntry fakeEntry = new WeightEntry(
        beginningDate,
        _calculateWeightOnBeginningDate(
            lastEntryBeforeBeginning, firstEntryAfterBeginning, beginningDate),
        null);
    entries.add(fakeEntry);
  }

  bool _isMissingEntryFromBeginningDate(DateTime beginningDate,
      List<WeightEntry> entries) {
    return !entries.any((entry) =>
    entry.dateTime.day == beginningDate.day &&
        entry.dateTime.month == beginningDate.month &&
        entry.dateTime.year == beginningDate.year);
  }

  bool _isAnyEntryBeforeBeginningDate(DateTime beginningDate,
      List<WeightEntry> entries) {
    return entries.any((entry) => entry.dateTime.isBefore(beginningDate));
  }

  double _calculateWeightOnBeginningDate(WeightEntry lastEntryBeforeBeginning,
      WeightEntry firstEntryAfterBeginning, DateTime beginningDate) {
    DateTime firstEntryDateTime =
    _copyDateWithoutTime(firstEntryAfterBeginning.dateTime);
    DateTime lastEntryDateTime =
    _copyDateWithoutTime(lastEntryBeforeBeginning.dateTime);

    int differenceInDays =
        firstEntryDateTime
            .difference(lastEntryDateTime)
            .inDays;
    double differenceInWeight =
        firstEntryAfterBeginning.weight - lastEntryBeforeBeginning.weight;
    int differenceInDaysFromBeginning =
        beginningDate
            .difference(lastEntryDateTime)
            .inDays;
    double weightChangeFromLastEntry =
        (differenceInWeight * differenceInDaysFromBeginning) / differenceInDays;
    double estimatedWeight =
        lastEntryBeforeBeginning.weight + weightChangeFromLastEntry;
    return estimatedWeight;
  }

  DateTime _copyDateWithoutTime(DateTime dateTime) {
    return new DateTime.utc(dateTime.year, dateTime.month, dateTime.day);
  }
}

class ChartPainter extends CustomPainter {
  final List<WeightEntry> entries;
  final int numberOfDays;

  ChartPainter(this.entries, this.numberOfDays);

  double leftOffsetStart;
  double topOffsetEnd;
  double drawingWidth;
  double drawingHeight;

  static const int NUMBER_OF_HORIZONTAL_LINES = 5;

  @override
  void paint(Canvas canvas, Size size) {
    leftOffsetStart = size.width * 0.07;
    topOffsetEnd = size.height * 0.9;
    drawingWidth = size.width * 0.93;
    drawingHeight = topOffsetEnd;

    if (entries.isEmpty) {
      _drawParagraphInsteadOfChart(
          canvas, size, "Add your current weight to see history");
    } else {
      Tuple2<int, int> borderLineValues = _getMinAndMaxValues(entries);
      _drawHorizontalLinesAndLabels(
          canvas, size, borderLineValues.item1, borderLineValues.item2);
      _drawBottomLabels(canvas, size);

      _drawLines(canvas, borderLineValues.item1, borderLineValues.item2);
    }
  }

  @override
  bool shouldRepaint(ChartPainter old) => true;

  ///draws actual chart
  void _drawLines(ui.Canvas canvas, int minLineValue, int maxLineValue) {
    final paint = new Paint()
      ..color = Colors.blue[400]
      ..strokeWidth = 3.0;
    DateTime beginningOfChart = _getStartDateOfChart(numberOfDays);
    for (int i = 0; i < entries.length - 1; i++) {
      Offset startEntryOffset = _getEntryOffset(
          entries[i], beginningOfChart, minLineValue, maxLineValue);
      Offset endEntryOffset = _getEntryOffset(
          entries[i + 1], beginningOfChart, minLineValue, maxLineValue);
      canvas.drawLine(startEntryOffset, endEntryOffset, paint);
      canvas.drawCircle(endEntryOffset, 3.0, paint);
    }
    canvas.drawCircle(
        _getEntryOffset(
            entries.first, beginningOfChart, minLineValue, maxLineValue),
        5.0,
        paint);
  }

  /// Draws horizontal lines and labels informing about weight values attached to those lines
  void _drawHorizontalLinesAndLabels(Canvas canvas, Size size, int minLineValue,
      int maxLineValue) {
    final paint = new Paint()
      ..color = Colors.grey[300];
    int lineStep = _calculateHorizontalLineStep(maxLineValue, minLineValue);
    double offsetStep = _calculateHorizontalOffsetStep;
    for (int line = 0; line < NUMBER_OF_HORIZONTAL_LINES; line++) {
      double yOffset = line * offsetStep;
      _drawHorizontalLabel(maxLineValue, line, lineStep, canvas, yOffset);
      _drawHorizontalLine(canvas, yOffset, size, paint);
    }
  }

  void _drawHorizontalLine(ui.Canvas canvas, double yOffset, ui.Size size,
      ui.Paint paint) {
    canvas.drawLine(
      new Offset(leftOffsetStart, 5 + yOffset),
      new Offset(size.width, 5 + yOffset),
      paint,
    );
  }

  void _drawHorizontalLabel(int maxLineValue, int line, int lineStep,
      ui.Canvas canvas, double yOffset) {
    ui.Paragraph paragraph =
    _buildParagraphForLeftLabel(maxLineValue, line, lineStep);
    canvas.drawParagraph(
      paragraph,
      new Offset(0.0, yOffset),
    );
  }

  /// Calculates offset difference between horizontal lines.
  ///
  /// e.g. between every line should be 100px space.
  double get _calculateHorizontalOffsetStep {
    return drawingHeight / (NUMBER_OF_HORIZONTAL_LINES - 1);
  }

  /// Calculates weight difference between horizontal lines.
  ///
  /// e.g. every line should increment weight by 5
  int _calculateHorizontalLineStep(int maxLineValue, int minLineValue) {
    return (maxLineValue - minLineValue) ~/ (NUMBER_OF_HORIZONTAL_LINES - 1);
  }

  void _drawBottomLabels(Canvas canvas, Size size) {
    for (int daysFromStart = numberOfDays;
    daysFromStart > 0;
    daysFromStart = (daysFromStart - (numberOfDays / 4)).round()) {
      double offsetXbyDay = drawingWidth / numberOfDays;
      double offsetX = leftOffsetStart + offsetXbyDay * daysFromStart;
      ui.Paragraph paragraph = _buildParagraphForBottomLabel(daysFromStart);
      canvas.drawParagraph(
        paragraph,
        new Offset(offsetX - 50.0, 10.0 + drawingHeight),
      );
    }
  }

  ///Builds paragraph for label placed on the bottom (dates)
  ui.Paragraph _buildParagraphForBottomLabel(int daysFromStart) {
    ui.ParagraphBuilder builder = new ui.ParagraphBuilder(
        new ui.ParagraphStyle(fontSize: 10.0, textAlign: TextAlign.right))
      ..pushStyle(new ui.TextStyle(color: Colors.black))
      ..addText(new DateFormat('d MMM').format(new DateTime.now()
          .subtract(new Duration(days: numberOfDays - daysFromStart))));
    final ui.Paragraph paragraph = builder.build()
      ..layout(new ui.ParagraphConstraints(width: 50.0));
    return paragraph;
  }

  ///Builds text paragraph for label placed on the left side of a chart (weights)
  ui.Paragraph _buildParagraphForLeftLabel(int maxLineValue, int line,
      int lineStep) {
    ui.ParagraphBuilder builder = new ui.ParagraphBuilder(
      new ui.ParagraphStyle(
        fontSize: 10.0,
        textAlign: TextAlign.right,
      ),
    )
      ..pushStyle(new ui.TextStyle(color: Colors.black))
      ..addText((maxLineValue - line * lineStep).toString());
    final ui.Paragraph paragraph = builder.build()
      ..layout(new ui.ParagraphConstraints(width: leftOffsetStart - 4));
    return paragraph;
  }

  ///Produces minimal and maximal value of horizontal line that will be displayed
  Tuple2<int, int> _getMinAndMaxValues(List<WeightEntry> entries) {
    double maxWeight = entries.map((entry) => entry.weight).reduce(math.max);
    double minWeight = entries.map((entry) => entry.weight).reduce(math.min);

    int maxLineValue;
    int minLineValue;

    if (maxWeight == minWeight) {
      maxLineValue = maxWeight.ceil() + 1;
      minLineValue = maxLineValue - 4;
    } else {
      maxLineValue = maxWeight.ceil();
      int difference = maxLineValue - minWeight.floor();
      int toSubtract = (NUMBER_OF_HORIZONTAL_LINES - 1) -
          (difference % (NUMBER_OF_HORIZONTAL_LINES - 1));
      if (toSubtract == NUMBER_OF_HORIZONTAL_LINES - 1) {
        toSubtract = 0;
      }
      minLineValue = minWeight.floor() - toSubtract;
    }
    return new Tuple2(minLineValue, maxLineValue);
  }

  /// Calculates offset at which given entry should be painted
  Offset _getEntryOffset(WeightEntry entry, DateTime beginningOfChart,
      int minLineValue, int maxLineValue) {
    int daysFromBeginning = entry.dateTime
        .difference(beginningOfChart)
        .inDays;
    double relativeXposition = daysFromBeginning / numberOfDays;
    double xOffset = leftOffsetStart + relativeXposition * drawingWidth;
    double relativeYposition =
        (entry.weight - minLineValue) / (maxLineValue - minLineValue);
    double yOffset = 5 + drawingHeight - relativeYposition * drawingHeight;
    return new Offset(xOffset, yOffset);
  }

  _drawParagraphInsteadOfChart(ui.Canvas canvas, ui.Size size, String text) {
    double fontSize = 14.0;
    ui.ParagraphBuilder builder = new ui.ParagraphBuilder(
      new ui.ParagraphStyle(
        fontSize: fontSize,
        textAlign: TextAlign.center,
      ),
    )
      ..pushStyle(new ui.TextStyle(color: Colors.black))
      ..addText(text);
    final ui.Paragraph paragraph = builder.build()
      ..layout(new ui.ParagraphConstraints(width: size.width));

    canvas.drawParagraph(
        paragraph, new Offset(0.0, size.height / 2 - fontSize));
  }
}

DateTime _getStartDateOfChart(int daysToSubtract) {
  DateTime now = new DateTime.now();
  DateTime beginningOfChart = now.subtract(
      new Duration(days: daysToSubtract, hours: now.hour, minutes: now.minute));
  return beginningOfChart;
}
